@abstractframework/continuum 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/bin/cli.js +16 -160
- package/bin/hub_proxy.js +52 -43
- package/bin/request_gate.js +43 -0
- package/bin/server.js +232 -0
- package/bin/settings.js +45 -28
- package/dist/assets/{abnfDiagram-VRR7QNED-C3nZSPxd.js → abnfDiagram-VRR7QNED-CptTkH57.js} +2 -2
- package/dist/assets/{arc-Co6CTENJ.js → arc-oQ5R3mHX.js} +2 -2
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-CEz8QvCN.js → architectureDiagram-ZJ3FMSHR-Bn00f0nM.js} +2 -2
- package/dist/assets/{blockDiagram-677ZJIJ3-B_5v6oR-.js → blockDiagram-677ZJIJ3-BgQg2xzI.js} +2 -2
- package/dist/assets/{c4Diagram-LMCZKHZV-B2dYGCte.js → c4Diagram-LMCZKHZV-DSrFKkwF.js} +2 -2
- package/dist/assets/channel-BBUeEZaL.js +2 -0
- package/dist/assets/{chunk-2Q5K7J3B-2eGZLgzu.js → chunk-2Q5K7J3B-D8JcDR0u.js} +2 -2
- package/dist/assets/{chunk-32BRIVSS-szXiGdYg.js → chunk-32BRIVSS-Di3jxbyw.js} +2 -2
- package/dist/assets/{chunk-5VM5RSS4-F7rd4WOC.js → chunk-5VM5RSS4-xA0TZltq.js} +2 -2
- package/dist/assets/{chunk-EX3LRPZG-BBaSONus.js → chunk-EX3LRPZG-BkNMQroi.js} +2 -2
- package/dist/assets/{chunk-JWPE2WC7-BzQLSiNr.js → chunk-JWPE2WC7-DaTATpXD.js} +2 -2
- package/dist/assets/{chunk-MOJQB5TN-CV4r6R-P.js → chunk-MOJQB5TN-CzzOSJWb.js} +2 -2
- package/dist/assets/{chunk-RYQCIY6F-lSwdPTG2.js → chunk-RYQCIY6F-DAhqq1xl.js} +2 -2
- package/dist/assets/{chunk-V7JOEXUC-BVe47Wh6.js → chunk-V7JOEXUC-D88CIHey.js} +2 -2
- package/dist/assets/{chunk-VR4S4FIN-Xsn7KCEd.js → chunk-VR4S4FIN-CV5xjwAx.js} +2 -2
- package/dist/assets/{chunk-XXDRQBXY-DdG9ww2-.js → chunk-XXDRQBXY-CHnfAGOy.js} +2 -2
- package/dist/assets/classDiagram-OUVF2IWQ-GIwsZVZi.js +2 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-GIwsZVZi.js +2 -0
- package/dist/assets/{cose-bilkent-JH36ORCC-DDdy2AQ-.js → cose-bilkent-JH36ORCC-BgTNPlnQ.js} +2 -2
- package/dist/assets/{cynefin-VYW2F7L2-PkaGuTvX.js → cynefin-VYW2F7L2-DFRQoaMD.js} +3 -3
- package/dist/assets/{cynefinDiagram-TSTJHNR4-BHIvz7Or.js → cynefinDiagram-TSTJHNR4-DA2MOQXI.js} +2 -2
- package/dist/assets/{dagre-VKFMJZFB-Dt336PVs.js → dagre-VKFMJZFB-BUIShmyq.js} +2 -2
- package/dist/assets/{diagram-FQU43EPY-DrmE36bq.js → diagram-FQU43EPY-DVjgItuu.js} +2 -2
- package/dist/assets/{diagram-G47NLZAW-tpUlukyV.js → diagram-G47NLZAW-eJSMzGjb.js} +2 -2
- package/dist/assets/{diagram-NH7WQ7WH-CO5Zb4_d.js → diagram-NH7WQ7WH-uJNNsz13.js} +2 -2
- package/dist/assets/{diagram-OA4YK3LP-B0FwAFRE.js → diagram-OA4YK3LP-xmJewu37.js} +2 -2
- package/dist/assets/{diagram-WEI45ONY-CcLxvhZ0.js → diagram-WEI45ONY-CaXYx2ei.js} +2 -2
- package/dist/assets/{ebnfDiagram-CCIWWBDH-CdGlfEot.js → ebnfDiagram-CCIWWBDH-BrgvjnU5.js} +2 -2
- package/dist/assets/{erDiagram-Q63AITRT-CxYfUHK8.js → erDiagram-Q63AITRT-CFF8G5wJ.js} +2 -2
- package/dist/assets/{flowDiagram-23GEKE2U-fW4F9z69.js → flowDiagram-23GEKE2U-W02vEFp0.js} +2 -2
- package/dist/assets/{ganttDiagram-NO4QXBWP-BWM-HcND.js → ganttDiagram-NO4QXBWP-CRV1W7ht.js} +2 -2
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-BlcnohGg.js → gitGraphDiagram-IHSO6WYX-CAe9Kkrb.js} +2 -2
- package/dist/assets/index-Cn7OlSFa.js +121 -0
- package/dist/assets/index-D1NTcviR.css +1 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-pJSOWVdi.js → infoDiagram-FWYZ7A6U-DzPZb8XV.js} +2 -2
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CpvrrPMQ.js → ishikawaDiagram-FXEZZL3T-CUSZtsJ2.js} +2 -2
- package/dist/assets/{journeyDiagram-5HDEW3XC-DzFKBKHk.js → journeyDiagram-5HDEW3XC-Ce4DZjHq.js} +2 -2
- package/dist/assets/{kanban-definition-HUTT4EX6-BPmBQCPR.js → kanban-definition-HUTT4EX6-B2aNvJXN.js} +2 -2
- package/dist/assets/{linear-DmNa6TPI.js → linear-Cy9Vf-l3.js} +2 -2
- package/dist/assets/{mermaid.core-_-YW0o95.js → mermaid.core-rvwxvqSK.js} +6 -6
- package/dist/assets/{mindmap-definition-LN4V7U3C-Bdwo0rlk.js → mindmap-definition-LN4V7U3C-LIaA-8NC.js} +2 -2
- package/dist/assets/{pegDiagram-2B236MQR-CHZoedfQ.js → pegDiagram-2B236MQR-D3veo_Oo.js} +2 -2
- package/dist/assets/{pieDiagram-ENE6RG2P-Blp676Hr.js → pieDiagram-ENE6RG2P-DryYCAYE.js} +2 -2
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-DhWHDdBX.js → quadrantDiagram-ABIIQ3AL-CQY6rt5A.js} +2 -2
- package/dist/assets/{railroadDiagram-RFXS5EU6-CEgbBDEg.js → railroadDiagram-RFXS5EU6-0QiEYfyx.js} +2 -2
- package/dist/assets/{requirementDiagram-TGXJPOKE-Ds8x8FbV.js → requirementDiagram-TGXJPOKE-CHk5lpA2.js} +2 -2
- package/dist/assets/{sankeyDiagram-HTMAVEWB-yKIsBDhP.js → sankeyDiagram-HTMAVEWB-CYPx25gJ.js} +2 -2
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-DAgYlOsI.js → sequenceDiagram-DBY2YBRQ-BSUr-JBi.js} +2 -2
- package/dist/assets/{sizeCapture-X5ZJPWSS-C-LAlxdw.js → sizeCapture-X5ZJPWSS-B6ZebkVB.js} +2 -2
- package/dist/assets/{stateDiagram-2N3HPSRC-BLiS6piZ.js → stateDiagram-2N3HPSRC-Dmqo36MA.js} +2 -2
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CwN_yVcy.js +2 -0
- package/dist/assets/swimlanes-5IMT3BWC-CuvrNUfQ.js +3 -0
- package/dist/assets/swimlanesDiagram-G3AALYLV-BFp8yG0X.js +9 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-BrJ5JJYj.js → timeline-definition-FHXFAJF6-DSmOxhA8.js} +2 -2
- package/dist/assets/{vennDiagram-L72KCM5P-BT9M9-9s.js → vennDiagram-L72KCM5P-DKkd_lMz.js} +2 -2
- package/dist/assets/{wardleyDiagram-EHGQE667-CukiKHz8.js → wardleyDiagram-EHGQE667-DYYxdBBD.js} +2 -2
- package/dist/assets/{xychartDiagram-FW5EYKEG-CIidttLL.js → xychartDiagram-FW5EYKEG-Qsc3YQdG.js} +2 -2
- package/dist/index.html +5 -2
- package/package.json +3 -3
- package/dist/assets/channel-D3yEdLfb.js +0 -2
- package/dist/assets/classDiagram-OUVF2IWQ-DJJxVI5p.js +0 -2
- package/dist/assets/classDiagram-v2-EOCWNBFH-DJJxVI5p.js +0 -2
- package/dist/assets/index-CFGJJTR4.js +0 -123
- package/dist/assets/index-DAHZ6K6R.css +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-CdmuVCk9.js +0 -2
- package/dist/assets/swimlanes-5IMT3BWC-Bj_dHHPI.js +0 -3
- package/dist/assets/swimlanesDiagram-G3AALYLV-CYrgsk-L.js +0 -9
package/README.md
CHANGED
|
@@ -72,7 +72,11 @@ abstractcontinuum
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Open `http://localhost:3002` and sign in with a gateway user and token (the
|
|
75
|
-
connection badge at the bottom of the sidebar).
|
|
75
|
+
connection badge at the bottom of the sidebar). When your gateway manages
|
|
76
|
+
Continuum (the gateway console's **Apps** screen), open it from there
|
|
77
|
+
instead: the gateway serves it at `/apps/continuum/` on its own port, so a
|
|
78
|
+
remote or headless machine needs one port and one tunnel for the console and
|
|
79
|
+
every app. The token is exchanged
|
|
76
80
|
server-side for an HttpOnly session cookie and never stored in the browser.
|
|
77
81
|
|
|
78
82
|
The server is configured with launch flags or saved settings (full list in
|
|
@@ -85,8 +89,9 @@ abstractcontinuum config get # every setting and where it come
|
|
|
85
89
|
```
|
|
86
90
|
|
|
87
91
|
- `--port` (default `3002`), `--host` (default `127.0.0.1`)
|
|
88
|
-
- `--gateway-url` — the gateway this
|
|
89
|
-
|
|
92
|
+
- `--gateway-url` (aliases `--gateway`, `--url`) — the gateway this
|
|
93
|
+
deployment talks to (default: the gateway installed on this computer, from
|
|
94
|
+
`~/.abstractframework/gateway.json`, else `http://127.0.0.1:8080`)
|
|
90
95
|
- `--hub-url` / `--hub-seat` / `--hub-token-file` — the agora hub, your seat
|
|
91
96
|
and its key for the Team page. The hub seat is also on the Settings page.
|
|
92
97
|
|
package/bin/cli.js
CHANGED
|
@@ -6,23 +6,26 @@
|
|
|
6
6
|
* shared app-origin session proxy (first-party cookies + CSRF; tokens never
|
|
7
7
|
* in URLs) — the same @abstractframework/app-server module the observer,
|
|
8
8
|
* flow, and code apps converge on.
|
|
9
|
+
*
|
|
10
|
+
* Mountable under the gateway at /apps/continuum/ (the kit's mount
|
|
11
|
+
* contract): every response announces `X-AbstractFramework-App:
|
|
12
|
+
* continuum; mount=1`, the page gets `<base href="<base path>/">` and
|
|
13
|
+
* `base_path`, and every "is this the operator's own computer?" check reads
|
|
14
|
+
* the kit's requestContext (bin/request_gate.js). Served at `/` on its own
|
|
15
|
+
* port it works exactly as before.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
13
|
-
import { join, extname, dirname } from 'path';
|
|
18
|
+
import { readFileSync } from 'fs';
|
|
19
|
+
import { join, dirname } from 'path';
|
|
14
20
|
import { fileURLToPath } from 'url';
|
|
15
|
-
import {
|
|
16
|
-
import { createHubProxy } from './hub_proxy.js';
|
|
21
|
+
import { createContinuumServer } from './server.js';
|
|
17
22
|
import {
|
|
18
23
|
SETTINGS,
|
|
19
24
|
SOURCE_WORDS,
|
|
20
25
|
apply_session_proxy_gates,
|
|
21
26
|
createLiveSettings,
|
|
22
|
-
createSettingsRoute,
|
|
23
27
|
default_settings_path,
|
|
24
28
|
display_value,
|
|
25
|
-
expand_home,
|
|
26
29
|
parse_args,
|
|
27
30
|
read_settings_file,
|
|
28
31
|
resolve_settings,
|
|
@@ -53,6 +56,10 @@ function help_text() {
|
|
|
53
56
|
const def = spec.type === 'bool' ? 'off' : spec.default === '' ? 'none' : String(spec.default);
|
|
54
57
|
lines.push(col(left, spec.help));
|
|
55
58
|
lines.push(`${' '.repeat(32)}default ${def}; setting ${spec.key}`);
|
|
59
|
+
if (spec.key === 'gateway_url') {
|
|
60
|
+
lines.push(`${' '.repeat(32)}also --gateway <url>, --url <url>; with none set, the gateway`);
|
|
61
|
+
lines.push(`${' '.repeat(32)}installed on this computer (~/.abstractframework/gateway.json)`);
|
|
62
|
+
}
|
|
56
63
|
if (spec.key === 'hub_token') {
|
|
57
64
|
lines.push(col('--hub-token-file <path>', 'read the hub token from a file (never printed)'));
|
|
58
65
|
lines.push(`${' '.repeat(32)}setting hub_token`);
|
|
@@ -159,169 +166,18 @@ const PORT = AT_START.port.value;
|
|
|
159
166
|
// binds are an explicit deployment choice (--host).
|
|
160
167
|
const HOST = AT_START.host.value;
|
|
161
168
|
const DEFAULT_GATEWAY_URL = AT_START.gateway_url.value;
|
|
162
|
-
|
|
163
169
|
// The session proxy reads its hardening gates from the environment only;
|
|
164
170
|
// hand it the resolved values (flag > setting > environment > default).
|
|
165
171
|
apply_session_proxy_gates(AT_START, process.env);
|
|
166
172
|
|
|
167
|
-
const
|
|
168
|
-
'.html': 'text/html',
|
|
169
|
-
'.js': 'application/javascript',
|
|
170
|
-
'.css': 'text/css',
|
|
171
|
-
'.json': 'application/json',
|
|
172
|
-
'.png': 'image/png',
|
|
173
|
-
'.jpg': 'image/jpeg',
|
|
174
|
-
'.jpeg': 'image/jpeg',
|
|
175
|
-
'.gif': 'image/gif',
|
|
176
|
-
'.svg': 'image/svg+xml',
|
|
177
|
-
'.ico': 'image/x-icon',
|
|
178
|
-
'.webmanifest': 'application/manifest+json',
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
function getMimeType(filePath) {
|
|
182
|
-
const ext = extname(filePath).toLowerCase();
|
|
183
|
-
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Content-Security-Policy for the app document (security adversary P1
|
|
187
|
-
// 2026-07-16): untrusted markdown (channel files, messages, attachments)
|
|
188
|
-
// renders in this app, and standard markdown allows remote images — a
|
|
189
|
-
// crafted  would beacon the operator's IP
|
|
190
|
-
// and read-timing to an external host the moment a preview opens. img-src
|
|
191
|
-
// 'self' data: kills that class app-wide. script-src 'self' is a backstop
|
|
192
|
-
// belt (the renderer already cannot emit script). ws:/wss: are needed
|
|
193
|
-
// because 'self' does not reliably cover scheme-different WebSocket dials;
|
|
194
|
-
// only our own bundle can open sockets (script-src), so this stays tight.
|
|
195
|
-
const CSP = [
|
|
196
|
-
"default-src 'self'",
|
|
197
|
-
"script-src 'self'",
|
|
198
|
-
"style-src 'self' 'unsafe-inline'", // React style={} attributes
|
|
199
|
-
"img-src 'self' data:",
|
|
200
|
-
"font-src 'self' data:",
|
|
201
|
-
"connect-src 'self' ws: wss:",
|
|
202
|
-
"object-src 'none'",
|
|
203
|
-
"base-uri 'self'",
|
|
204
|
-
"form-action 'self'",
|
|
205
|
-
"frame-ancestors 'none'",
|
|
206
|
-
].join('; ');
|
|
207
|
-
|
|
208
|
-
function serveFile(res, filePath) {
|
|
209
|
-
try {
|
|
210
|
-
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
const content = readFileSync(filePath);
|
|
214
|
-
const mime = getMimeType(filePath);
|
|
215
|
-
const headers = {
|
|
216
|
-
'Content-Type': mime,
|
|
217
|
-
'Cache-Control': 'no-cache',
|
|
218
|
-
'X-Content-Type-Options': 'nosniff',
|
|
219
|
-
};
|
|
220
|
-
// CSP is a document policy — attach it where a document loads.
|
|
221
|
-
if (mime === 'text/html') headers['Content-Security-Policy'] = CSP;
|
|
222
|
-
res.writeHead(200, headers);
|
|
223
|
-
res.end(content);
|
|
224
|
-
return true;
|
|
225
|
-
} catch {
|
|
226
|
-
return false;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Cookies abstractcontinuum_gateway_* and header x-abstractcontinuum-csrf
|
|
231
|
-
// derive from appId.
|
|
232
|
-
const gatewaySessionProxy = createGatewaySessionProxy({
|
|
233
|
-
appId: 'abstractcontinuum',
|
|
234
|
-
defaultGatewayUrl: DEFAULT_GATEWAY_URL,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// Team page transport: /api/hub/* forwards an allowlisted hub API subset
|
|
238
|
-
// with the OPERATOR seat's key attached server-side (proposal c1692,
|
|
239
|
-
// agency contract c1696 — authorship stays the operator's own).
|
|
240
|
-
// Every hub setting is a getter: a seat saved on the Settings page or with
|
|
241
|
-
// `abstractcontinuum config set` applies without a restart.
|
|
242
|
-
const hubProxy = createHubProxy({
|
|
243
|
-
hubUrl: () => settings.value('hub_url'),
|
|
244
|
-
seat: () => settings.value('hub_seat'),
|
|
245
|
-
keysPath: () => expand_home(settings.value('hub_keys')),
|
|
246
|
-
token: () => settings.value('hub_token'),
|
|
247
|
-
allowRemote: () => settings.value('hub_allow_remote'),
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// The Settings page's door to this server's own settings (hub seat).
|
|
251
|
-
const settingsRoute = createSettingsRoute({ live: settings });
|
|
252
|
-
|
|
253
|
-
const server = http.createServer((req, res) => {
|
|
254
|
-
// Parse against a FIXED base: a malformed Host header (e.g. "a b") makes
|
|
255
|
-
// new URL throw synchronously, which would kill the whole process from
|
|
256
|
-
// one bad request (adversarial P1 2026-07-13). The host plays no role in
|
|
257
|
-
// routing here — only the pathname does.
|
|
258
|
-
let pathname = '/';
|
|
259
|
-
let search = '';
|
|
260
|
-
try {
|
|
261
|
-
const u = new URL(req.url, 'http://local');
|
|
262
|
-
pathname = u.pathname;
|
|
263
|
-
search = u.search;
|
|
264
|
-
} catch {
|
|
265
|
-
res.writeHead(400);
|
|
266
|
-
res.end('Bad Request');
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (settingsRoute.handle(req, res, pathname)) {
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
if (hubProxy.handle(req, res, pathname, search)) {
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (gatewaySessionProxy.handle(req, res, pathname)) {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (pathname.includes('..')) {
|
|
283
|
-
res.writeHead(400);
|
|
284
|
-
res.end('Bad Request');
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
if (pathname === '/' || pathname === '') {
|
|
289
|
-
pathname = '/index.html';
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const filePath = join(DIST_DIR, pathname);
|
|
293
|
-
if (serveFile(res, filePath)) return;
|
|
294
|
-
if (serveFile(res, filePath + '.html')) return;
|
|
295
|
-
|
|
296
|
-
// SPA fallback
|
|
297
|
-
if (serveFile(res, join(DIST_DIR, 'index.html'))) return;
|
|
298
|
-
|
|
299
|
-
res.writeHead(404);
|
|
300
|
-
res.end('Not Found');
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
// Live hub updates: relay browser WebSockets to the hub with the seat key
|
|
304
|
-
// attached server-side (operator c2240 — realtime, not 5s polling).
|
|
305
|
-
server.on('upgrade', (req, socket, head) => {
|
|
306
|
-
// F7 (dm-99 tail): a raw upgrade socket with no 'error' listener is a
|
|
307
|
-
// process-killer (browser aborts mid-handshake raise uncaught 'error');
|
|
308
|
-
// and a rejected relay promise without .catch is an unhandled rejection.
|
|
309
|
-
socket.on('error', () => {
|
|
310
|
-
try { socket.destroy(); } catch { /* already gone */ }
|
|
311
|
-
});
|
|
312
|
-
void hubProxy.handleUpgrade(req, socket, head).then((handled) => {
|
|
313
|
-
if (!handled) socket.destroy();
|
|
314
|
-
}).catch(() => {
|
|
315
|
-
try { socket.destroy(); } catch { /* already gone */ }
|
|
316
|
-
});
|
|
317
|
-
});
|
|
173
|
+
const server = createContinuumServer({ settings, distDir: DIST_DIR });
|
|
318
174
|
|
|
319
175
|
server.listen(PORT, HOST, () => {
|
|
320
176
|
const seat = AT_START.hub_seat;
|
|
321
177
|
console.log(`
|
|
322
178
|
AbstractContinuum is running.
|
|
323
179
|
Local: http://localhost:${PORT}
|
|
324
|
-
Gateway: ${DEFAULT_GATEWAY_URL}
|
|
180
|
+
Gateway: ${DEFAULT_GATEWAY_URL} (${SOURCE_WORDS[AT_START.gateway_url.source]})
|
|
325
181
|
Hub seat: ${seat.value} (${SOURCE_WORDS[seat.source]})
|
|
326
182
|
Settings: ${SETTINGS_PATH}
|
|
327
183
|
`);
|
package/bin/hub_proxy.js
CHANGED
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
import { readFileSync } from 'fs';
|
|
21
21
|
import { fileURLToPath } from 'url';
|
|
22
22
|
import { dirname, join } from 'path';
|
|
23
|
+
import { APP_IDENTITY_HEADER, MountRequestError, identityHeaderValue, rejectUpgrade } from '@abstractframework/app-server';
|
|
24
|
+
import { request_gate } from './request_gate.js';
|
|
25
|
+
|
|
26
|
+
/** The gateway catalog id this server announces (`/apps/continuum/`). */
|
|
27
|
+
export const APP_ID = 'continuum';
|
|
23
28
|
|
|
24
29
|
/** Console version for the hub's X-Agora-Client handshake (read once at
|
|
25
30
|
* boot; "0.0.0" only if package.json is unreadable — the header's job is
|
|
@@ -190,30 +195,6 @@ const HUB_ROUTES = [
|
|
|
190
195
|
{ method: 'DELETE', re: /^\/channels\/[A-Za-z0-9_.:-]+\/reputation\/[A-Za-z0-9_.-]+$/ },
|
|
191
196
|
];
|
|
192
197
|
|
|
193
|
-
/**
|
|
194
|
-
* Cross-origin refusal (adversary P0, 2026-07-15): the loopback peer gate
|
|
195
|
-
* defends against REMOTE peers, but the browser itself is a loopback peer —
|
|
196
|
-
* a hostile page the operator visits can fire cross-origin fetches and
|
|
197
|
-
* WebSocket handshakes at this proxy (WS handshakes skip CORS preflight
|
|
198
|
-
* entirely), and the seat key rides server-side. Rule: when the browser
|
|
199
|
-
* declares an Origin, it must match the Host the request was addressed to;
|
|
200
|
-
* absent Origin (curl, scripts, same-origin GET navigations) passes — a
|
|
201
|
-
* browser NEVER omits Origin on cross-origin fetch/WS, so the vector this
|
|
202
|
-
* closes cannot dodge the check.
|
|
203
|
-
*/
|
|
204
|
-
function origin_allowed(req) {
|
|
205
|
-
const origin = String((req.headers && req.headers.origin) || '').trim();
|
|
206
|
-
if (!origin) return true;
|
|
207
|
-
let parsed;
|
|
208
|
-
try {
|
|
209
|
-
parsed = new URL(origin);
|
|
210
|
-
} catch {
|
|
211
|
-
return false;
|
|
212
|
-
}
|
|
213
|
-
const host = String((req.headers && req.headers.host) || '').trim().toLowerCase();
|
|
214
|
-
return Boolean(host) && parsed.host.toLowerCase() === host;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
198
|
function read_seat_key(keys_path, hub_url, seat, token) {
|
|
218
199
|
// An explicit token (--hub-token / --hub-token-file / the hub_token
|
|
219
200
|
// setting, resolved by bin/settings.js) wins over the key store.
|
|
@@ -427,13 +408,20 @@ export function createHubProxy(opts) {
|
|
|
427
408
|
socket.on('error', () => {
|
|
428
409
|
try { socket.destroy(); } catch { /* already gone */ }
|
|
429
410
|
});
|
|
411
|
+
// Same gate as the HTTP surface (the BROWSER's address, which behind
|
|
412
|
+
// the gateway is the forwarded one), PLUS the origin gate against the
|
|
413
|
+
// host the browser addressed — WS handshakes have no CORS preflight,
|
|
414
|
+
// so without it any page the operator visits could open this relay
|
|
415
|
+
// and read/write as the operator seat (adversary P0).
|
|
416
|
+
let gate;
|
|
417
|
+
try {
|
|
418
|
+
gate = request_gate(req);
|
|
419
|
+
} catch (e) {
|
|
420
|
+
if (!(e instanceof MountRequestError)) throw e;
|
|
421
|
+
rejectUpgrade(socket, e.status, e.message);
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
430
424
|
try {
|
|
431
|
-
// Same unforgeable peer gate as the HTTP surface, PLUS the origin
|
|
432
|
-
// gate — WS handshakes have no CORS preflight, so without it any
|
|
433
|
-
// page the operator visits could open this relay and read/write
|
|
434
|
-
// as the operator seat (adversary P0).
|
|
435
|
-
const peer = String(req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : '');
|
|
436
|
-
const loopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1' || peer === '';
|
|
437
425
|
const allow_remote = cfg().allow_remote;
|
|
438
426
|
const seat_key = key();
|
|
439
427
|
let WebSocketServer, WebSocket;
|
|
@@ -443,7 +431,15 @@ export function createHubProxy(opts) {
|
|
|
443
431
|
socket.destroy();
|
|
444
432
|
return true;
|
|
445
433
|
}
|
|
446
|
-
if (
|
|
434
|
+
if (!gate.loopback && !allow_remote) {
|
|
435
|
+
rejectUpgrade(socket, 403, 'The hub relay answers this computer only (it authors as the operator seat).');
|
|
436
|
+
return true;
|
|
437
|
+
}
|
|
438
|
+
if (!gate.same_origin) {
|
|
439
|
+
rejectUpgrade(socket, 403, 'Cross-origin WebSocket to the hub relay refused.');
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
if (!seat_key) {
|
|
447
443
|
socket.destroy();
|
|
448
444
|
return true;
|
|
449
445
|
}
|
|
@@ -451,7 +447,13 @@ export function createHubProxy(opts) {
|
|
|
451
447
|
// one side of a string-pipe relay ships RSV1 frames the other side
|
|
452
448
|
// never negotiated (live find: browsers/ws clients offer it by
|
|
453
449
|
// default and the pipe corrupts).
|
|
454
|
-
if (!this._wss)
|
|
450
|
+
if (!this._wss) {
|
|
451
|
+
this._wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
|
|
452
|
+
// The 101 announces the app like every other response
|
|
453
|
+
// (X-AbstractFramework-App: continuum; mount=1).
|
|
454
|
+
const identity = `${APP_IDENTITY_HEADER}: ${identityHeaderValue(APP_ID)}`;
|
|
455
|
+
this._wss.on('headers', (headers) => headers.push(identity));
|
|
456
|
+
}
|
|
455
457
|
const hub_ws_url = cfg().hub_url.replace(/^http/, 'ws') + '/ws';
|
|
456
458
|
const upstream = new WebSocket(hub_ws_url, {
|
|
457
459
|
headers: { Authorization: `Bearer ${seat_key}` },
|
|
@@ -532,24 +534,31 @@ export function createHubProxy(opts) {
|
|
|
532
534
|
handle(req, res, pathname, search) {
|
|
533
535
|
if (!pathname.startsWith('/api/hub/') && pathname !== '/api/hub/meta') return false;
|
|
534
536
|
|
|
535
|
-
//
|
|
536
|
-
// proxy authors hub messages AS THE OPERATOR, so a
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
542
|
-
|
|
537
|
+
// Client gate (entity's c1768 class, applied at the mount): this
|
|
538
|
+
// proxy authors hub messages AS THE OPERATOR, so a browser on another
|
|
539
|
+
// machine could forge the operator's authorship. The client is the
|
|
540
|
+
// kit's requestContext answer (bin/request_gate.js): the socket peer,
|
|
541
|
+
// or behind the gateway's /apps/continuum/ (a loopback peer) the
|
|
542
|
+
// address the gateway forwarded — never a header from anyone else.
|
|
543
|
+
let gate;
|
|
544
|
+
try {
|
|
545
|
+
gate = request_gate(req);
|
|
546
|
+
} catch (e) {
|
|
547
|
+
if (!(e instanceof MountRequestError)) throw e;
|
|
548
|
+
res.writeHead(e.status, { 'Content-Type': 'application/json' });
|
|
549
|
+
res.end(JSON.stringify({ error: 'bad_request', detail: e.message }));
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
543
552
|
const allow_remote = cfg().allow_remote;
|
|
544
|
-
if (!loopback && !allow_remote) {
|
|
553
|
+
if (!gate.loopback && !allow_remote) {
|
|
545
554
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
546
|
-
res.end(JSON.stringify({ error: 'hub_proxy_non_loopback', detail: `Hub proxy refuses
|
|
555
|
+
res.end(JSON.stringify({ error: 'hub_proxy_non_loopback', detail: `Hub proxy refuses a browser on another machine (${gate.ctx.clientAddress}): it authors as the operator seat. Start Continuum with --hub-allow-remote only behind a trusted front.` }));
|
|
547
556
|
return true;
|
|
548
557
|
}
|
|
549
558
|
// Cross-origin browser requests are refused even from loopback: the
|
|
550
559
|
// browser is a loopback peer, and a hostile page can fire simple
|
|
551
560
|
// POSTs (no preflight) at this surface. Origin-vs-Host is the gate.
|
|
552
|
-
if (!
|
|
561
|
+
if (!gate.same_origin) {
|
|
553
562
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
554
563
|
res.end(JSON.stringify({ error: 'hub_proxy_cross_origin', detail: 'Cross-origin requests to the hub proxy are refused (the proxy authors as the operator seat).' }));
|
|
555
564
|
return true;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is this request from this computer, and from this app's own page?" —
|
|
3
|
+
* the ONE answer every app-local privileged route uses (the hub proxy,
|
|
4
|
+
* which authors as the operator's seat, and the Settings route, which
|
|
5
|
+
* steers it).
|
|
6
|
+
*
|
|
7
|
+
* Both questions go through @abstractframework/app-server's
|
|
8
|
+
* `requestContext(req)`: behind the gateway's `/apps/continuum/` the socket
|
|
9
|
+
* peer is ALWAYS loopback (the gateway), so the socket address says nothing
|
|
10
|
+
* about the browser. The kit believes the gateway's X-Forwarded-For /
|
|
11
|
+
* X-Forwarded-Host only from a loopback peer, and answers the browser's
|
|
12
|
+
* address and the host the browser addressed.
|
|
13
|
+
*
|
|
14
|
+
* - loopback: the BROWSER runs on this computer (`clientIsLoopback`).
|
|
15
|
+
* - same_origin: a declared Origin names the host the browser addressed
|
|
16
|
+
* (the forwarded host behind the gateway, else the Host header). An
|
|
17
|
+
* absent Origin passes: a browser never omits it on a cross-origin
|
|
18
|
+
* fetch or WebSocket handshake, so the vector this closes cannot dodge
|
|
19
|
+
* the check.
|
|
20
|
+
*
|
|
21
|
+
* Throws MountRequestError (status 400) for a malformed forwarded header or
|
|
22
|
+
* an unknown socket peer; callers refuse the request.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { requestContext } from '@abstractframework/app-server';
|
|
26
|
+
|
|
27
|
+
export function request_gate(req) {
|
|
28
|
+
const ctx = requestContext(req);
|
|
29
|
+
return { ctx, loopback: ctx.clientIsLoopback, same_origin: origin_matches(req, ctx.host) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function origin_matches(req, host) {
|
|
33
|
+
const origin = String((req.headers && req.headers.origin) || '').trim();
|
|
34
|
+
if (!origin) return true;
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = new URL(origin);
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const want = String(host || '').trim().toLowerCase();
|
|
42
|
+
return Boolean(want) && parsed.host.toLowerCase() === want;
|
|
43
|
+
}
|
package/bin/server.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AbstractContinuum HTTP server: the built console, the app-origin
|
|
3
|
+
* gateway session proxy (/api/* → the gateway), the Team page's hub proxy
|
|
4
|
+
* (/api/hub/*, HTTP + the /api/hub/ws relay) and the Settings route.
|
|
5
|
+
*
|
|
6
|
+
* Mountable under the gateway at /apps/continuum/ (the
|
|
7
|
+
* @abstractframework/app-server mount contract): every response, the
|
|
8
|
+
* WebSocket 101 included, announces `X-AbstractFramework-App: continuum;
|
|
9
|
+
* mount=1`; the page gets `<base href="<base path>/">` and `base_path`;
|
|
10
|
+
* cookies carry `Path=<base path>/`; and every "is this the operator's own
|
|
11
|
+
* computer?" check reads the kit's requestContext (bin/request_gate.js),
|
|
12
|
+
* never the socket peer. Served at `/` on its own port it works exactly as
|
|
13
|
+
* before.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as http from 'http';
|
|
17
|
+
import { createHash } from 'crypto';
|
|
18
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
19
|
+
import { join, extname } from 'path';
|
|
20
|
+
import {
|
|
21
|
+
MountRequestError,
|
|
22
|
+
createGatewaySessionProxy,
|
|
23
|
+
createGatewayUrlResolver,
|
|
24
|
+
createMountedHandler,
|
|
25
|
+
injectShell,
|
|
26
|
+
rejectUpgrade,
|
|
27
|
+
requestContext,
|
|
28
|
+
} from '@abstractframework/app-server';
|
|
29
|
+
import { APP_ID, createHubProxy } from './hub_proxy.js';
|
|
30
|
+
import { createSettingsRoute, expand_home } from './settings.js';
|
|
31
|
+
|
|
32
|
+
const MIME_TYPES = {
|
|
33
|
+
'.html': 'text/html',
|
|
34
|
+
'.js': 'application/javascript',
|
|
35
|
+
'.css': 'text/css',
|
|
36
|
+
'.json': 'application/json',
|
|
37
|
+
'.png': 'image/png',
|
|
38
|
+
'.jpg': 'image/jpeg',
|
|
39
|
+
'.jpeg': 'image/jpeg',
|
|
40
|
+
'.gif': 'image/gif',
|
|
41
|
+
'.svg': 'image/svg+xml',
|
|
42
|
+
'.ico': 'image/x-icon',
|
|
43
|
+
'.webmanifest': 'application/manifest+json',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function getMimeType(filePath) {
|
|
47
|
+
const ext = extname(filePath).toLowerCase();
|
|
48
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Content-Security-Policy for the app document (security adversary P1
|
|
52
|
+
// 2026-07-16): untrusted markdown (channel files, messages, attachments)
|
|
53
|
+
// renders in this app, and standard markdown allows remote images — a
|
|
54
|
+
// crafted  would beacon the operator's IP
|
|
55
|
+
// and read-timing to an external host the moment a preview opens. img-src
|
|
56
|
+
// 'self' data: kills that class app-wide. script-src 'self' is a backstop
|
|
57
|
+
// belt (the renderer already cannot emit script). ws:/wss: are needed
|
|
58
|
+
// because 'self' does not reliably cover scheme-different WebSocket dials;
|
|
59
|
+
// only our own bundle can open sockets (script-src), so this stays tight.
|
|
60
|
+
const CSP = [
|
|
61
|
+
"default-src 'self'",
|
|
62
|
+
"script-src 'self'",
|
|
63
|
+
"style-src 'self' 'unsafe-inline'", // React style={} attributes
|
|
64
|
+
"img-src 'self' data:",
|
|
65
|
+
"font-src 'self' data:",
|
|
66
|
+
"connect-src 'self' ws: wss:",
|
|
67
|
+
"object-src 'none'",
|
|
68
|
+
"base-uri 'self'",
|
|
69
|
+
"form-action 'self'",
|
|
70
|
+
"frame-ancestors 'none'",
|
|
71
|
+
].join('; ');
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* settings: live settings (bin/settings.js createLiveSettings); distDir: the
|
|
75
|
+
* built console. The gateway URL, like the port and host, is read once from
|
|
76
|
+
* the settings at creation.
|
|
77
|
+
*/
|
|
78
|
+
export function createContinuumServer({ settings, distDir }) {
|
|
79
|
+
const AT_START = settings.get();
|
|
80
|
+
const DEFAULT_GATEWAY_URL = AT_START.gateway_url.value;
|
|
81
|
+
const DIST_DIR = distDir;
|
|
82
|
+
// A gateway URL nobody chose (the local gateway pointer, or the built-in
|
|
83
|
+
// default) follows the pointer while the server runs: the kit's resolver
|
|
84
|
+
// re-reads it when the gateway refuses a connection. A flag, setting or
|
|
85
|
+
// environment choice never moves.
|
|
86
|
+
const CHOSEN_GATEWAY = ['flag', 'setting', 'env'].includes(AT_START.gateway_url.source);
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The console document for one request: the kit's injectShell puts
|
|
90
|
+
* `<base href="<base path>/">` first in <head> (every relative asset and
|
|
91
|
+
* fetch resolves under /apps/continuum/ behind the gateway, under / on the
|
|
92
|
+
* app's own port) plus `window.__ABSTRACT_UI_CONFIG__` (gateway_url,
|
|
93
|
+
* base_path) in one inline script. The CSP admits exactly that script by
|
|
94
|
+
* its hash; every other inline script stays refused.
|
|
95
|
+
*/
|
|
96
|
+
function shellResponse(html, ctx) {
|
|
97
|
+
const page = injectShell(html, { basePath: ctx.basePath, config: { gateway_url: gatewaySessionProxy.defaultGatewayUrl } });
|
|
98
|
+
const inline = /<base [^>]*><script>([\s\S]*?)<\/script>/.exec(page);
|
|
99
|
+
if (!inline) throw new Error('injectShell produced no inline config script');
|
|
100
|
+
const hash = createHash('sha256').update(inline[1], 'utf8').digest('base64');
|
|
101
|
+
const csp = CSP.replace("script-src 'self'", `script-src 'self' 'sha256-${hash}'`);
|
|
102
|
+
return { page, csp };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serveFile(res, filePath, ctx) {
|
|
106
|
+
try {
|
|
107
|
+
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const mime = getMimeType(filePath);
|
|
111
|
+
const headers = {
|
|
112
|
+
'Content-Type': mime,
|
|
113
|
+
'Cache-Control': 'no-cache',
|
|
114
|
+
'X-Content-Type-Options': 'nosniff',
|
|
115
|
+
};
|
|
116
|
+
if (mime === 'text/html') {
|
|
117
|
+
// CSP is a document policy — attach it where a document loads.
|
|
118
|
+
const { page, csp } = shellResponse(readFileSync(filePath, 'utf8'), ctx);
|
|
119
|
+
headers['Content-Type'] = 'text/html; charset=utf-8';
|
|
120
|
+
headers['Content-Security-Policy'] = csp;
|
|
121
|
+
res.writeHead(200, headers);
|
|
122
|
+
res.end(page);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
res.writeHead(200, headers);
|
|
126
|
+
res.end(readFileSync(filePath));
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Cookies abstractcontinuum_gateway_* and header x-abstractcontinuum-csrf
|
|
134
|
+
// derive from appId; mounted, the cookies carry Path=/apps/continuum/.
|
|
135
|
+
const gatewaySessionProxy = createGatewaySessionProxy({
|
|
136
|
+
appId: 'abstractcontinuum',
|
|
137
|
+
defaultGatewayUrl: CHOSEN_GATEWAY ? DEFAULT_GATEWAY_URL : createGatewayUrlResolver({}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Team page transport: /api/hub/* forwards an allowlisted hub API subset
|
|
141
|
+
// with the OPERATOR seat's key attached server-side (proposal c1692,
|
|
142
|
+
// agency contract c1696 — authorship stays the operator's own).
|
|
143
|
+
// Every hub setting is a getter: a seat saved on the Settings page or with
|
|
144
|
+
// `abstractcontinuum config set` applies without a restart.
|
|
145
|
+
const hubProxy = createHubProxy({
|
|
146
|
+
hubUrl: () => settings.value('hub_url'),
|
|
147
|
+
seat: () => settings.value('hub_seat'),
|
|
148
|
+
keysPath: () => expand_home(settings.value('hub_keys')),
|
|
149
|
+
token: () => settings.value('hub_token'),
|
|
150
|
+
allowRemote: () => settings.value('hub_allow_remote'),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// The Settings page's door to this server's own settings (hub seat).
|
|
154
|
+
const settingsRoute = createSettingsRoute({ live: settings });
|
|
155
|
+
|
|
156
|
+
const server = http.createServer(createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
|
|
157
|
+
// Parse against a FIXED base: a malformed Host header (e.g. "a b") makes
|
|
158
|
+
// new URL throw synchronously, which would kill the whole process from
|
|
159
|
+
// one bad request (adversarial P1 2026-07-13). The host plays no role in
|
|
160
|
+
// routing here — only the pathname does.
|
|
161
|
+
let pathname = '/';
|
|
162
|
+
let search = '';
|
|
163
|
+
try {
|
|
164
|
+
const u = new URL(req.url, 'http://local');
|
|
165
|
+
pathname = u.pathname;
|
|
166
|
+
search = u.search;
|
|
167
|
+
} catch {
|
|
168
|
+
res.writeHead(400);
|
|
169
|
+
res.end('Bad Request');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (settingsRoute.handle(req, res, pathname)) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (hubProxy.handle(req, res, pathname, search)) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (gatewaySessionProxy.handle(req, res, pathname)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (pathname.includes('..')) {
|
|
186
|
+
res.writeHead(400);
|
|
187
|
+
res.end('Bad Request');
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (pathname === '/' || pathname === '') {
|
|
192
|
+
pathname = '/index.html';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const filePath = join(DIST_DIR, pathname);
|
|
196
|
+
if (serveFile(res, filePath, ctx)) return;
|
|
197
|
+
if (serveFile(res, filePath + '.html', ctx)) return;
|
|
198
|
+
|
|
199
|
+
// SPA fallback
|
|
200
|
+
if (serveFile(res, join(DIST_DIR, 'index.html'), ctx)) return;
|
|
201
|
+
|
|
202
|
+
res.writeHead(404);
|
|
203
|
+
res.end('Not Found');
|
|
204
|
+
}));
|
|
205
|
+
|
|
206
|
+
// Live hub updates: relay browser WebSockets to the hub with the seat key
|
|
207
|
+
// attached server-side (operator c2240 — realtime, not 5s polling).
|
|
208
|
+
server.on('upgrade', (req, socket, head) => {
|
|
209
|
+
// F7 (dm-99 tail): a raw upgrade socket with no 'error' listener is a
|
|
210
|
+
// process-killer (browser aborts mid-handshake raise uncaught 'error');
|
|
211
|
+
// and a rejected relay promise without .catch is an unhandled rejection.
|
|
212
|
+
socket.on('error', () => {
|
|
213
|
+
try { socket.destroy(); } catch { /* already gone */ }
|
|
214
|
+
});
|
|
215
|
+
// A malformed forwarded header or an unknown peer: refused before any
|
|
216
|
+
// route (the kit's rule; the hub relay applies it again for its gate).
|
|
217
|
+
try {
|
|
218
|
+
requestContext(req);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (!(e instanceof MountRequestError)) throw e;
|
|
221
|
+
rejectUpgrade(socket, e.status, e.message);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
void hubProxy.handleUpgrade(req, socket, head).then((handled) => {
|
|
225
|
+
if (!handled) rejectUpgrade(socket, 404, 'No WebSocket here.');
|
|
226
|
+
}).catch(() => {
|
|
227
|
+
try { socket.destroy(); } catch { /* already gone */ }
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return server;
|
|
232
|
+
}
|